Skip to content

Rewrite code-interpreter service in Go#65

Open
jmelahman wants to merge 7 commits into
mainfrom
kanban/python-sandbox/golang-controller
Open

Rewrite code-interpreter service in Go#65
jmelahman wants to merge 7 commits into
mainfrom
kanban/python-sandbox/golang-controller

Conversation

@jmelahman

@jmelahman jmelahman commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds code-interpreter-go/, a full Go port of the Python code-interpreter service, alongside the existing implementation (the Python service is untouched, so both can run side by side during migration).

Same API surface and payloads: GET /health, POST /v1/execute, POST /v1/execute/stream (SSE output/result/error events), /v1/files CRUD, /v1/sessions with TTL-enforced teardown + reaper, and POST /v1/sessions/{id}/bash. Same environment-based configuration.

Benchmarks (Python vs Go)

Measured with the committed harness (benchmarks/, methodology and reproduction steps in benchmarks/README.md) on a 24-CPU Linux host; two full runs, medians shown as ranges. Both services ran natively with identical config and load.

Metric Python Go Delta
Cold start → first served request 271–763 ms 21–30 ms 13–25× faster
CPU time to readiness 0.26–0.73 s 0.02–0.03 s ~13–24× less
Idle RSS 48 MiB 31 MiB ~36% less
Endpoint (single instance) Python req/s Go req/s Speedup
download 100 KiB (16 workers) 1,227–1,717 8,330–9,870 ~6–8×
list files (16 workers) 517–590 14,859–18,179 ~25–35×
execute validation path (1 worker) 880–1,160 9,694–11,355 ~8–13×
upload 100 KiB (4 workers) 292–477 1,711–2,269 ~4–6×

The Python service plateaus under concurrency (GIL: 16-worker download throughput barely exceeds 1-worker); matching Go's 16-worker throughput would take ~6–8 uvicorn workers (~300–400 MiB RSS) vs one 43 MiB Go process.

Image sizes (compressed): the published Python image is 194 MB (registry-measured; 104 MB of that is the docker-ce layer, 45 MB the python base, ~38 MB venv/uv). The Go -slim image derives to ~45 MB (30 MB debian base + 13 MB gzipped binary) and the Go default to ~150 MB. Application payload: one 46 MB static binary vs 84 MB CPython + 129 MB venv.

Caveat: end-to-end /v1/execute latency is not benchmarked (needs a Docker daemon; the bench host had no CAP_SYS_ADMIN). It is dominated by container create/start, which both implementations pay identically; they differ in orchestration overhead (Python spawns ~6 docker-CLI subprocesses per execution, Go makes Engine API calls on one socket).

Key design changes vs. the Python service

  • Docker Engine API instead of the docker CLI (github.com/docker/docker/client): typed not-found/conflict errors replace stderr-string heuristics, exec-attach with demultiplexed streams feeds SSE, and image pull happens over the API. File staging and workspace snapshots still run tar inside the container over the exec API, because /workspace is a tmpfs mount the engine's copy API writes past invisibly.
  • PYTHON_EXECUTOR_DOCKER_RUN_ARGS is now a documented subset (--label, --env, --add-host, --dns, --ulimit) and the service fails fast at startup on unsupported flags instead of silently passing them through. PYTHON_EXECUTOR_DOCKER_BIN is no longer read; the daemon connection uses standard DOCKER_HOST et al.
  • entrypoint.sh is built into the binary (internal/bootstrap): mode detection (kubernetes / external DOCKER_HOST / mounted socket / DinD), cgroup v2 nesting, nested dockerd launch + supervision, and readiness via SDK ping instead of docker info. The image runs the binary directly — no shell needed at runtime.
  • Hardened, slimmer images: bases default to Docker Hardened Images from dhi.io (override via BUILD_IMAGE/RUNTIME_IMAGE), the Docker daemon is installed only for DinD support, and curl/gnupg/supervisor/bash are gone. SKIP_NESTED_DOCKER=1 builds support Docker-out-of-Docker with zero docker packages (the Python image needed the CLI for DooD).
  • Kubernetes backend on client-go with SPDY exec — binary-safe streams remove the tar | base64 round-trip.
  • Bug fix: ephemeral container keepalive was timeout_ms * 1000 seconds (multi-day sleeps on a crashed API); now timeout_ms/1000 + 10 seconds.
  • Validation error bodies are {"detail": "<message>"} strings (not Pydantic's array format); status codes unchanged. /docs//redoc//openapi.json are not served.

Packaging & CI

  • Two image variants per release, defined in docker-bake.hcl: onyxdotapp/code-interpreter-go:<version> (default, includes the Docker daemon — works in every deployment mode) and :<version>-slim (zero docker packages, for DooD/Kubernetes). docker buildx bake local builds the host's native platform for development.
  • Release workflow builds each platform on a native runner (ubuntu-latest / ubuntu-24.04-arm, no QEMU), pushes by digest, and merges the multi-arch manifests with imagetools create. The Go build stage cross-compiles from $BUILDPLATFORM.
  • New golang-checks CI job: gofmt / build / vet / unit tests, plus the real-daemon Docker executor e2e test against the runner's daemon.

Testing

  • go build ./..., go vet ./..., gofmt clean; go test ./... green across all packages.
  • Unit tests: image ref normalization, path validation, tar round-trips, incremental UTF-8 stream decoding with output caps, run-args parsing, container specs, file storage, bootstrap mode decision.
  • httptest-based API tests with a fake executor mirroring tests/integration_tests coverage (execute, streaming SSE, files upload/download/list/delete incl. 413, sessions, bash, health).
  • The Docker executor e2e test runs in CI against a real daemon (golang-checks job).
  • Not verified yet: the image builds against the dhi.io bases (validate the refs/auth with a workflow_dispatch dry run before the first release) and the privileged DinD bootstrap path, which needs a --privileged deployment to exercise.

🤖 Generated with Claude Code

jmelahman and others added 7 commits July 9, 2026 20:24
Add code-interpreter-go/, a full port of the Python FastAPI service with
the same API surface (/health, /v1/execute, /v1/execute/stream SSE,
/v1/files, /v1/sessions, session bash) and the same env-based
configuration.

Notable changes from the Python implementation:
- Docker backend uses the Docker Engine API (github.com/docker/docker/client)
  instead of shelling out to the docker CLI; PYTHON_EXECUTOR_DOCKER_RUN_ARGS
  now accepts a documented flag subset and fails fast on anything else.
  Workspace staging/snapshot still run tar inside the container since
  /workspace is a tmpfs the engine copy API cannot see.
- entrypoint.sh is absorbed into the binary (mode detection, cgroup v2
  nesting, nested dockerd launch + SDK ping readiness); the image runs the
  binary directly with no shell required at runtime.
- Dockerfile defaults to DHI hardened base images (overridable build args),
  installs the Docker daemon only for DinD builds, and drops curl/gnupg/
  supervisor. SKIP_NESTED_DOCKER=1 builds now support DooD with zero docker
  packages.
- Kubernetes backend on client-go with SPDY exec (no tar|base64 round-trip).
- Fixes a keepalive unit slip (timeout_ms * 1000 seconds -> timeout_ms/1000
  + 10 seconds).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Release both onyxdotapp/code-interpreter-go:<version> (with the Docker
daemon; every deployment mode works, including DinD) and :<version>-slim
(SKIP_NESTED_DOCKER=1, zero docker packages) so users opt OUT of the
daemon rather than discovering a broken default.

Add a Go CI job (gofmt/build/vet/test) plus the real-daemon Docker
executor e2e test against the runner's daemon, and document the
deployment modes in the README with rootless-DooD guidance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Move the full/-slim target definitions (tags, platforms, build args,
DHI base overrides) out of the release workflow into
code-interpreter-go/docker-bake.hcl so local builds and CI share one
definition, and both variants build as a single graph with the common
Go build stage deduplicated. The workflow now runs docker/bake-action
with per-target GHA cache scopes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Measured on a 24-CPU host: Go serves first request 13-25x sooner
(21-30ms vs 271-763ms median), uses ~36% less idle RSS (31 vs 48 MiB),
and sustains 6-35x higher API throughput under concurrency where the
Python service plateaus on the GIL. Registry-measured image sizes: the
published Python image is 194MB compressed vs ~45MB derived for the Go
-slim variant. Execution end-to-end is not covered here (no Docker
daemon available); CI's e2e test exercises that path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Building linux/arm64 on an amd64 host without binfmt registered fails
with "exec format error". Pin the Go build stage to BUILDPLATFORM and
cross-compile via GOOS/GOARCH so the toolchain never runs emulated,
add setup-qemu-action to the release job for the runtime stage's apt
layers, and document the local binfmt one-liner in the Dockerfile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace QEMU emulation in the release workflow with a native-runner
matrix (ubuntu-latest / ubuntu-24.04-arm): each platform builds and
pushes by digest via PUSH_BY_DIGEST, and a merge job assembles the
multi-arch manifest lists with imagetools. Add `bake local` targets
that build only the host's native platform and load into the local
image store, plus a dhi.io registry login for the hardened bases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant